跳到主要内容

Redis 实战:使用事务构建一个游戏商城

前言

这篇博客依旧是 《Redis 实战》 的学习笔记,主要记录如何在实际业务中使用事务

Redis 事务可以用来防止数据出错的方法,以及在某些情况下,使用事务来提升性能的方法。

Redis 的事务和传统关系数据库的事务并不相同。在关系数据库中,用户首先向数据库服务器发送 BEGIN,然后执行各个相互一致(consistent)的写操作和读操作,最后,用户可以选择发送 COMMIT 来确认之前所做的修改,或者发送 ROLLBACK 来放弃那些修改。

在 Redis 里面也有简单的方法可以处理一连串相互一致的读操作和写操作,Redis 的事务以特殊命令 MULTI 为开始,之后跟着用户传入的多个命令,最后以 EXEC 为结束。

但是由于这种简单的事务 在 EXEC 命令被调用之前不会执行任何实际操作,所以用户将没办法根据读取到的数据来做决定。这个问题看上去似乎无足轻重,但实际,上无法以一致的形式读取数据将导致某一类型的问题变得难以解决,除此之外,因为在多个事务同时处理同一个对象时通常需要用到二阶提交(two-phasecommit),所以如果事务不能以一致的形式读取数据,那么二阶提交将无法实现,从而导致一些原本可以成功执行的事务沦落至执行失败的地步。

比如说:“在市场里面购买一件商品” 就是其中一个会因为无法以一致的形式读取数据而变得难以解决的问题,本节接下来将在实际环境中对这个问题进行介绍。

补充:延迟执行事务有助于提升性能因为 Redis 在执行事务的过程中,会延迟执行已入队的命令直到客户端发送 EXEC 命令为止,而且无需一次次的响应结果。

先看看原来的多条命令,是如何执行的:

sequenceDiagram
Redis Client->>Redis Server: 发送第1个命令
Redis Server->>Redis Client: 响应第1个命令
Redis Client->>Redis Server: 发送第2个命令
Redis Server->>Redis Client: 响应第2个命令
Redis Client->>Redis Server: 发送第n个命令
Redis Server->>Redis Client: 响应第n个命令

Pipeling 机制是怎样的呢:

sequenceDiagram
Redis Client->>Redis Server: 发送第1个命令(缓存在Redis Client,未即时发送)
Redis Client->>Redis Server: 发送第2个命令(缓存在Redis Client,未即时发送)
Redis Client->>Redis Server: 发送第n个命令(缓存在Redis Client,未即时发送)
Redis Client->>Redis Server: 发送累积的命令
Redis Server->>Redis Client: 响应第1、2、n个命令

节约了响应的时间,所以非常快

回顾:事务的命令

命令描述
DISCARD取消事务,放弃执行事务块内的所有命令。
EXEC执行所有事务块内的命令。
MULTI标记一个事务块的开始。
UNWATCH取消 WATCH 命令对所有 key 的监视。
WATCH key监视一个(或多个) key ,如果在事务执行之前这个 key 被其他命令所改动,那么事务将被打断。

前置知识:watch 监控事务

在 Redis 中使用 watch 命令可以决定事务是执行还是回滚。一般而言,可以在 multi 命令之前使用 watch 命令监控某些键值对,然后使用 multi 命令开启事务,执行各类对数据结构进行操作的命令,这个时候这些命令就会进入队列。

当 Redis 使用 exec 命令执行事务的时候,它首先会去比对被 watch 命令所监控的键值对,如果没有发生变化,那么它会执行事务队列中的命令,提交事务;

如果发生变化,那么它不会执行任何事务中的命令,而去事务回滚。

无论事务是否回滚,Redis 都会去取消执行事务前的 watch 命令,这个过程如图 1 所示。

Redis 参考了多线程中 使用的 CAS(比较与交换,Compare And Swap)去执行的。在数据高并发环境的操作中,我们把这样的一个机制称为乐观锁。

需求~

最近几个月,Fake Game 公司发现他们在 YouTwitFace(一个虚构的社交网站)上面推出的角色扮演网页游戏正在变得越来越受欢迎。因此,关心玩家需求的 Fake Game 公司决定在游戏里面增加一个商品买卖市场,让玩家们可以在市场里面销售和购买商品。

定义用户信息和商品信息

图 4-2 展示了游戏中用于表示用户信息和用户包裹(inventory,就是用户的背包)的结构:用户信息存储在一个散列里面,散列的各个键值对分别记录了用户的姓名、用户拥有的钱数等属性。

用户包裹(背包)使用一个集合来表示,它记录了包裹里面每件商品的唯一编号。

商品买卖市场的需求非常简单:一个用户(卖家)可以将自己的商品按照给定的价格放到市场上进行销售,当另一个用户(买家)购买这个商品时,卖家就会收到钱。(这里的商品队列按照价格排序)

为了将被销售商品的全部信息都存储到市场里面,将商品的 ID 和卖家的 ID 拼接起来,并将拼接的结果用作成员存储到市场有序集合(market ZSET)里面,而商品的售价则用作成员的分值(score)。

通过将所有数据都包含在一起,极大地简化了实现商品买卖市场所需的数据结构,并且因为市场里面的所有商品都按照价格排序,所以针对商品的分页功能和查找功能都可以很容易地实现。

图 4-3 展示了一个只包含数个商品的市场例子。

既然我们已经知道了实现商品买卖市场所需的数据结构,那么接下来该考虑如何实现市场的商品上架功能了。

将商品放到市场上销售

为了将商品放到市场上进行销售,程序除了要使用 MULTI 命令和 EXEC 命令之外,还需要配合使用 WATCH 命令,有时候甚至还会用到 UNWATCH 或 DISCARD 命令。

在用户使用 WATCH 命令对键进行监视之后,直到用户执行 EXEC 命令的这段时间里面,如果有其他客户端抢先对任何被监视的键进行了替换、更新或删除等操作,那么当用户尝试执行 EXEC 命令的时候,事务将失败并返回一个错误(之后用户可以选择重试事务或者放弃事务)。

通过使用 WATCH、MULTI/EXEC 、UNWATCH/DISCARD 等命令,程序可以在执行某些重要操作的时候,通过确保自己正在使用的数据没有发生变化来避免数据出错。

在将一件商品放到市场上进行销售的时候,程序需要将被销售的商品添加到记录市场正在销售商品的有序集合里面,并且在添加操作执行的过程中,监视卖家的包裹,以确保被销售的商品的确存在于卖家的包裹当中。如果是的话就会将被销售的商品添加到买卖市场里面,并从卖家的包裹中移除该商品。

正如函数中的 while 循环所示,在使用 WATCH 命令对包裹进行监视的过程中,如果包裹被更新或者修改,那么程序将重试。

/**
* 对卖家的包裹进行监视,验证卖家想要销售的商品是否依然还存在卖家包裹里面,如果是的话就
* 会将被销售的商品添加到买卖市场里面,并从卖家的包裹中移除该商品。正如函数中的while循
* 环所示,在使用 WATCH 命令对包裹进行监视的过程中,如果包裹被更新或者修改,那么程序将接
* 收到错误并进行重试。
*
* @param conn Redis 连接
* @param itemId 商品 ID
* @param sellerId 卖家 ID
* @param price 价格
* @return 是否发布到市场成功
*/
public boolean listItem(Jedis conn, String itemId, String sellerId, double price) {
// 得到用户背包的 key
String inventory = "inventory:" + sellerId;
// 将商品的 ID 和卖家的 ID 拼接起来
String item = itemId + '.' + sellerId;
long end = System.currentTimeMillis() + 5000;
while (System.currentTimeMillis() < end) { // 这里的 while 循环确实就是 CAS
// 监视用户包裹发生的变化
conn.watch(inventory);
// sismember方法:如果 member 是存储在 key 上的集合的成员,则返回 true,否则返回 false。
if (Boolean.FALSE.equals(conn.sismember(inventory, itemId))) {
// 如果指定的商品不在用户背包里面,那么停止对背包键的监视
conn.unwatch();
return false;
}
// 启动事务
Transaction trans = conn.multi();
// 把商品放在市场里面(score 是价格)
trans.zadd("market:", price, item);
// 从卖家背包中删除这个道具
trans.srem(inventory, itemId);
List<Object> results = trans.exec(); // 提交事务
// 如果响应为 Null 表示由于被监视的键更改而中止事务。
if (results == null) {
continue;
}
return true;
}
return false;
}

图 4-4 展示了当 Frank(用户 ID 为 17)尝试以 97 块钱的价格销售 ItemM 时,listItem() 函数的执行过程。

因为程序会确保用户只能销售他们自己所拥有的商品,所以在一般情况下,用户都可以顺利地将自已想要销售的商品添加到商品买卖市场上面,但是正如之前所说,如果用户的包裹在 WATCH 执行之后直到 EXEC 执行之前的这段时间内发生了变化,那么添加操作将执行失败并重试。

购买商品

程序首先使用 WATCH 对市场以及买家的个人信息进行监视,然后获取买家拥有的钱数以及商品的售价,并检查买家是否有足够的钱来购买该商品。如果买家没有足够的钱,那么程序会取消事务;相反地,如果买家的钱足够,那么程序首先会将买家支付的钱转移给卖家,然后将售出的商品移动至买家的包裹,并将该商品从市场中移除。

当买家的个人信息或者商品买卖市场出现变化而导致 WatchError 异常出现时,程序将进行重试,其中最大重试时间为 10 秒。

/**
* 购买商品
* @param conn Redis 连接
* @param buyerId 购买者 ID
* @param itemId 商品 ID
* @param sellerId 卖方 ID
* @param lprice 交易价格
* @return 是否购买成功
*/
public boolean purchaseItem(Jedis conn, String buyerId, String itemId, String sellerId, double lprice) {
String buyer = "users:" + buyerId;
String seller = "users:" + sellerId;
String item = itemId + '.' + sellerId;
String inventory = "inventory:" + buyerId;
// 最大重试 10 秒
long end = System.currentTimeMillis() + 10000;
while (System.currentTimeMillis() < end) {
// 开始交易前先对市场以及买家的个人信息进行监控
conn.watch("market:", buyer); // 这个 watch 可以监视多个 key(这里同时监视玩家背包和商城)
// 检查买家想要购买的商品的价格是否出现变化,以及买家是否有足够的钱来购买这件商品
double price = conn.zscore("market:", item);
// 取得买方当前的现金
double funds = Double.parseDouble(conn.hget(buyer, "funds"));
if (price != lprice || price > funds) {
conn.unwatch();
return false;
}
// 上面的判断都没有问题则开启事务,将买方的钱转给卖方
Transaction trans = conn.multi();
trans.hincrBy(seller, "funds", (int) price);
trans.hincrBy(buyer, "funds", (int) -price);
// 将被购商品放入买家背包里面
trans.sadd(inventory, itemId);
// 从市场移除这个 item
trans.zrem("market:", item);
List<Object> results = trans.exec();
// 如果 results 为 null 表示事务失败了,表示买家的个人信息或者商品市场在交易过程出现了变化,那么重试
if (results == null) {
continue;
}
return true;
}
return false;
}

在执行商品购买操作的时候,程序除了需要花费大量时间来准备相关数据之外,还需要对商品买卖市场以及买家的个人信息进行监视:监视商品买卖市场是为了确保买家想要购买的商品仍然有售(或者在商品已经被其他人买走时进行提示),而监视买家的个人信息则是为了验证买家是否有足够的钱来购买自已想要的商品。

当程序确认商品仍然存在并且买家有足够钱的时候,程序会将被购买的商品移动到买家的包裹里面,并将买家支付的钱转移给卖家。

在观察了市场上展示的商品之后,Bill(用户 ID 为 27)决定购买 Frank 在市场上销售的 ItemM,图 4-5 和图 4-6 展示了购买操作执行期间,数据结构是如何变化的。

正如之前的代码清单 4-6 所示,如果商品买卖市场有序集合(market ZSET)或者Bill 的个人信息在 WATCH 和 EXEC 执行之间发生了变化,那么 purchaseItem 将进行重试,或者在重试操作超时之后放弃此次购买操作。

性能优化

现在来回顾一下商品的购物过程。当玩家在市场上购买商品的时候,程序首先需要使用 WATCH 去监视市场以及买家的个人信息散列,在得知买家现有的钱数以及商品的售价之后,程序会验证买家是否有足够的钱来购买指定的商品:

// 开始交易前先对市场以及买家的个人信息进行监控
conn.watch("market:", buyer); // 这个 watch 可以监视多个 key(这里同时监视玩家背包和商城)

如果买家有足够的钱,那么程序会将买家支付的钱转移给卖家,接着将商品添加到买家的包裹里面,并从市场里面移除已被售出的商品

相反地,如果买家没有足够的钱来购买商品,那么程序就会取消事务。在执行购买操作的过程中,如果有其他玩家对市场进行了改动,或者因为记录买家个人信息的散列出现了变化而引发了 WATCH 错误,那么程序将重新执行购买操作。

Redis 参考了多线程中 使用的 CAS(比较与交换,Compare And Swap)去执行的。在数据高并发环境的操作中,我们把这样的一个机制称为乐观锁。

但是,它有个很严重的问题,虽然它不阻塞线程,但是可能会因为冲突过多,导致重试的次数也过多,为了展示锁对于性能扩展的必要性,我们会模拟市场在 3 种不同负载情况下的性能表现,这 3 种情况分别是:

  • 1 个玩家出售商品,另 1 个玩家购买商品;
  • 5 个玩家出售商品,另 1 个玩家购买商品;
  • 以及 5 个玩家出售商品,另外 5 个玩家购买商品。

表 6-1 展示了模拟的结果。

根据表 6-1 的模拟结果显示,随着负载不断增加,系统完成一次交易所需的重试次数从最初的 3 次上升到了 250 次,与此同时,完成一次交易所需的等待时间也从最初的少于 10ms 上升到了 500ms。

这个模拟示例完美地展示了为什么 WATCH、MULTI 和 EXEC 组成的事务并不具有可扩展性,原因在于程序在尝试完成一个事务的时候,可能会因为事务执行失败而反复地进行重试。保证数据的正确性是一件非常重要的事情,但使用 WATCH 命令的做法并不完美。

为了解决这个问题,并以可扩展的方式来处理市场交易,我们将使用锁来保证市场在任一时 刻只能上架或者销售一件商品。

锁的使用看下一篇博客

完整的源码

Gist 地址